#
#      Copyright 2011 Battams, Derek
#       
#       Licensed under the Apache License, Version 2.0 (the "License");
#       you may not use this file except in compliance with the License.
#       You may obtain a copy of the License at
#
#          http://www.apache.org/licenses/LICENSE-2.0
#
#       Unless required by applicable law or agreed to in writing, software
#       distributed under the License is distributed on an "AS IS" BASIS,
#       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#       See the License for the specific language governing permissions and
#       limitations under the License.
#
#	$Id: sage.py 244 2011-10-03 22:59:12Z tom.miranda@gmail.com $

'''A python interface to the sagex SageTV HTTP API.

This module provides a natural python look and feel to the sagex HTTP API and
allows you to interact with your SageTV system from python scripts.

This module has the following external requirements:

	* The sagex remote API plugin must be installed on your SageTV server
	  and the HTTP interface must be enabled.
	* If you're using a python version older than 2.6 then you must include
	  the simplejson package and ensure it's accessible at runtime.  When
	  executed under python 2.6, this module will use the builtin json 
	  package for JSON processing.
	* To use any of the module mediaFileTo*() methods, you must be running
	  in the Boxee environment.  If you're not and attempt to use any of
	  these methods they will simply return None.
'''
import urllib, logging, re, threading, string, livetv
try:
	import simplejson as json # For older versions of python, including that used in Boxee environment
except ImportError:
	import json
import os.path as path
from threading import RLock
from datetime import date
from time import sleep
try:
	import mc
	IS_BOXEE_ENV = True
except ImportError:
	IS_BOXEE_ENV = False
	
def getSageVersion(api):
	'''Get the version number of the SageTV server as a string'''
	rawVersion = api.GetProperty("version","")
	if rawVersion == None or rawVersion == '':
		return "0"
	else:
		return re.sub("[^0-9.]", "", rawVersion)

def _unicodeToStr(obj):
	'''Convert all unicode strings in a JSON object to 8-bit ascii encoded utf-8 strings'''
	t = type(obj)
	if(t is unicode):
		return obj.encode(DEFAULT_CHARSET)
	elif(t is list):
		for i in range(0, len(obj)):
			obj[i] = _unicodeToStr(obj[i])
		return obj
	elif(t is dict):
		for k in obj.keys():
			v = obj[k]
			del obj[k]
			obj[k.encode(DEFAULT_CHARSET)] = _unicodeToStr(v)
		return obj
	else:
		return obj # leave numbers and booleans alone

def _isValidResponse(resp):
	return isinstance(resp, dict) and not _isValidError(resp)

def _isValidError(resp):
	return isinstance(resp, dict) and resp.has_key(PROP_ERROR) and resp.has_key(PROP_EXCEPTION)

def objToListItem(obj):
	'''Convert an arbitrary python object to a Boxee ListItem object.

		This function simply creates a ListItem with the Label set to the str() value of obj.

		obj: Any object to be converted to a ListItem
		return: A ListItem with it's label set to the str() of the argument
	'''
	if(not IS_BOXEE_ENV):
		return None
	item = mc.ListItem(mc.ListItem.MEDIA_VIDEO_EPISODE)
	try:
		item.SetLabel(str(obj))
	except UnicodeEncodeError:
		item.SetLabel(obj.encode(DEFAULT_CHARSET))
	except Exception, e:
		LOG.LogError('Unknown error: %s', e)
	return item

def objListToListItems(list):
	'''Convert a list of arbitrary python objects to a Boxee ListItems object.

		This function simply creates a ListItems obj containing the converted elements of the given list argument.

		list: A list of objects.
		return: A ListItems obj where each element of list is converted to a ListItem and pushed onto the ListItems obj
	'''
	if(not IS_BOXEE_ENV):
		return None
	items = mc.ListItems()
	for i in list:
		item = objToListItem(i)
		if(item != None):
			items.append(item)
	return items
	
def getAiringOnChannelAtTime(stationID, t, api):
	expr = 'GetElement(GetAiringsOnChannelAtTime(GetChannelForStationID("%s"), %s, %s, false),0)' % (stationID, t, t)
	airing = api.EvaluateExpression(expr)
	return airing
	
def getAiringsOnViewableChannelsAtTime(t, api):
	expr = 'GetAiringsOnViewableChannelsAtTime(%s, %s, false)' % (t, t)
	airings = api.EvaluateExpression(expr)
	return airings
	
def setAiringGenreAndBackground(item, airing):
	item.SetGenre('None')
	color = {'Movie':'GuideAiringBGMovie.png', 'Film':'GuideAiringBGMovie.png', 'News':'GuideAiringBGNews.png', 'Newsmagazine':'GuideAiringBGNews.png', 'Sports event':'GuideAiringBGSports.png', 'Sports non-event':'GuideAiringBGSports.png'}	
	if (airing.has_key('Show') and airing['Show'].has_key('ShowCategory')):
		category = airing['Show']['ShowCategory']
		if (category != '' and color.has_key(category)):
			item.SetProperty('Background', color[category])
			item.SetGenre(category)
		else:
			item.SetProperty('Background', 'Background.png')
			
	# Set the more detailed genre, if it's available.
	if (airing.has_key('Show') and airing['Show'].has_key('ShowCategoriesString') and airing['Show']['ShowCategoriesString'] != ''):
		item.SetGenre(str(airing['Show']['ShowCategoriesString']))
		
	return item
	
def isSameAiring(airing1, airing2):
	if not airing1.has_key('AiringID'):
		return False
	if not airing2.has_key('AiringID'):
		return False
	return airing1['AiringID'] == airing2['AiringID']

class SageApi:
	'''A python wrapper for the SageTV API; allows API calls via the sagex remote HTTP API interface.

		Typical usage:

		from sage import SageApi

		api = SageApi('192.168.0.10:8080', 'myid', 'mypwd')
		print api.GetOS() # Prints 'Windows XP' on my system
		print api.GetMediaFiles('T') # Prints all MediaFile objects that are TV recordings

		See the /sagex/api page on your SageTV Jetty server for details on all available API calls.
	'''
	_host = None
	_id = None
	_pwd = None
	_ssl = False
	_auth = None

	def __init__(self, host = 'localhost', id = None, pwd = None, ssl = False):
		'''Constructor.

			host: The SageTV hostname to connect to; if the port is not 80 or 443 (if using SSL) then you must also supply the port number by adding ':port' to the end of the host name (i.e. 192.168.1.10:8080)
			  id: The Jetty username to conenct as; if your sagex API is not protected via id/pwd then you don't need to provide this, but you really should protect it!
			 pwd: The Jetty password to connect with
			 ssl: If true, connect to the API via SSL (encrypted connection) otherwise just use a standard HTTP connection; default is false (i.e. don't use SSL); your Jetty plugin must be configured for SSL if you wish to use it here
		'''
		self._host = host
		self._id = id
		self._pwd = pwd
		self._ssl = ssl
		LOG.LogDebug('sagex API Creds: %s' % ((host, id, '*****', ssl),))

	def __getattr__(self, name):
		'''Converts an attribute request to a single sagex HTTP API call.

			  name: The name of the Sage API call to be made
			return: A callable python object capable of making the Sage API call as identified by the given attribute name
		'''
		if(re.match('[a-z]', name[0])):
			return _SageApiHelpers(self).__getattr__(name)
		else:
			LOG.LogDebug('Dispatching sagex API call: %s' % name)
			return _SagexHttpApi(name, self)

	def _getUrlPrefix(self):
		url = 'http'
		if self._ssl:
			url += 's'
		url += '://'
		if self._id != None and self._pwd != None:
			url += '%s:%s@' % (self._id, self._pwd)
		return url + self._host

class _SageApiHelpers:
	_api = None

	def __init__(self, api):
		self._api = api

	def __getattr__(self, name):
		api = self._api
		if(name == 'getUniqueTvTitles'):
			return _dummyCallable(api.EvaluateExpression('Sort(java_util_Map_keySet(GroupByMethod(GetMediaFiles("T"),"GetMediaTitle")),false,"Natural")'))
		elif(name == 'isRecordingSomething'):
			return _dummyCallable(api.EvaluateExpression('Size(GetCurrentlyRecordingMediaFiles()) > 0'))
		elif(name == 'isApiServerAlive'):
			try:
				return _dummyCallable(api.EvaluateExpression('Size(GetOS()) > 0'))
			except:
				LOG.LogError('Sage API server appears to be unavailable!')
				return False
		elif(name == 'getMediaFilesForTitle'):
			return _apiExprEvaluator(api, 'FilterByMethod(GetMediaFiles("T"), "GetMediaTitle", "%s", true)')
		elif(name == 'getChannelLogoUrl'):
			return _strExpander(api)
		elif(name == 'getChannelLogoUrlLarge'):
			return _strExpanderLarge(api)		
		elif(name=='getSeriesImage'):
			return _seriesImageExpander(api)
		elif(name == 'recordAndWait'):
			return _recordAndWait(api)
		elif(name == 'schedAndWait'):
			return _schedAndWait(api)
		else:
			raise SageApiIoException('Invalid Sage API call made! [%s]' % name)

class _recordAndWait:
	'''Attempt to start recording an airing and return the MediaFile for the new recording or None if the recording did not start.'''
	_api = None

	def __init__(self, api):
		self._api = api

	def __call__(self, airing, maxWait = 8, minWait = 1):
		# In the case where the recording does not start, this method will take 15 seconds to return; lower the maxWait time after further testing to improve this perf
		wait = minWait
		isRecording = False
		try:
			self._api.Record(airing)
		except:
			pass
		LOG.LogDebug('Record command issued to server.')
		while not isRecording and wait <= maxWait:
			LOG.LogDebug('Checking if recording has started...')
			sleep(wait)
			wait *= 2
			isRecording = _recordAndWait.__dict__['checkRecording'](self, airing)
			LOG.LogDebug('Recording started: %s' % isRecording)
		if isRecording:
			return self._api.GetMediaFileForAiring(airing)
		else:
			return None

	def checkRecording(self, airing):
		for mf in self._api.GetCurrentlyRecordingMediaFiles():
			if _objToArg(mf['Airing']) == _objToArg(airing):
				return True
		return False
		
class _schedAndWait:
	'''Attempt to schedule a manual recording and return the True if it worked or False if it did not.'''
	_api = None

	def __init__(self, api):
		self._api = api

	def __call__(self, airing, maxWait = 8, minWait = 1):
		# In the case where the manual record causes a conflict, this method will take 15 seconds to return; lower the maxWait time after further testing to improve this perf
		wait = minWait
		isScheduled = False
		try:
			self._api.Record(airing)
		except:
			pass
		LOG.LogDebug('Record command issued to server.')
		while not isScheduled and wait <= maxWait:
			LOG.LogDebug('Checking if recording has been scheduled...')
			sleep(wait)
			wait *= 2
			isScheduled = _schedAndWait.__dict__['checkScheduled'](self, airing)
			LOG.LogDebug('Recording scheduled: %s' % isScheduled)
		return isScheduled

	def checkScheduled(self, airing):
		expr = 'GetAiringsThatWontBeRecorded(true)'
		for mf in self._api.EvaluateExpression(expr):
			if mf['AiringID'] == airing.GetProperty('AiringID'):
				return False
		return True		
			
class _strExpander:

	_url = None

	def __init__(self, api):
		self._url = '%s/sagex/media/logo/%%s?type=Small' % api._getUrlPrefix()

	def __call__(self, name):
		return self._url % name
		
class _strExpanderLarge:

	_url = None

	def __init__(self, api):
		self._url = '%s/sagex/media/logo/%%s?type=Large' % api._getUrlPrefix()

	def __call__(self, name):
		return self._url % name		
		
class _seriesImageExpander:

	_url = None
	
	def __init__(self, api):
		self._url = '%s/sagex/api?c=GetSeriesImage&1=airing:%%s&encoder=image' % api._getUrlPrefix()
		
	def __call__(self, airingID):
		return self._url % airingID
		
class _dummyCallable:
	''' Simulate callable objects for internal API helper methods.'''
	_rc = None

	def __init__(self, rc):
		self._rc = rc

	def __call__(self, *args):
		return self._rc

class _apiExprEvaluator:

	_api = None
	_expr = None

	def __init__(self, api, expr):
		self._api = api
		self._expr = expr

	def __call__(self, *args):
		return self._api.EvaluateExpression(self._expr % args)

class _SagexHttpApi:
	'''Encapsulates a single sagex remote HTTP API call.'''
	_api = None
	_name = None

	def __init__(self, name, api):
		self._api = api
		self._name = name

	def __call__(self, *args, **kvargs):
		url = self._api._getUrlPrefix() + '/sagex/api?c=' + self._name
		if not kvargs.has_key('size'):
			kvargs['size'] = 50000
		kvargs['encoder'] = 'json' # Force the use of the json encoder for all API calls; it's required (based on the implementation below)
		for k in kvargs.keys():
			val = kvargs[k]
			if val != None and len(str(val)) > 0:
				url += '&%s=%s' % (urllib.quote_plus(k), urllib.quote_plus(_objToArg(val)))
		for i in range(0, len(args)):
			url += '&%s=%s' % (str(i + 1), urllib.quote_plus(_objToArg(args[i])))
		LOG.LogDebug('sagex request URL: %s' % url)
		try:
			input = _SageApiFancyUrlOpener().open(url)
		except IOError, e:
			raise SageApiIoException('Network error', e)
		payload = input.read()
		resp = _unicodeToStr(json.JSONDecoder().decode(payload))
		if(_isValidResponse(resp)):
			LOG.LogInfo('sagex API call "%s" [args: %s] succeeded!' % (self._name, args))
			LOG.LogDebug('sagex API call: valid response received (1KB max logged)\n\n%s' % payload[0:1024])
			objKeys = resp.keys()
			numKeys = len(objKeys)
			if(numKeys == 1):
				return resp[objKeys[0]]
			elif(numKeys == 0):
				return None
			else:
				raise SageApiIoException('Unrecognized response!')
			
		elif(_isValidError(resp)):
			LOG.LogInfo('sagex API call "%s" [args: %s] FAILED!' % (self._name, args))
			raise SageApiException(resp)
		else:
			LOG.LogInfo('sagex API call "%s" [args: %s] generated unrecognized response!' % (self._name, args))
			LOG.LogDebug('sagex API call: INVALID response received!\n\n%s' % payload)
			raise SageApiIoException(resp)

class SageApiException(Exception):
	'''Thrown when a sagex API call returns an error response.'''

	_resp = None
	_msg = None
	_err = None
	_exception = None

	def __init__(self, errResp, msg = None):
		self._resp = errResp
		self._msg = msg
		self._err = errResp[PROP_ERROR]
		self._exception = errResp[PROP_EXCEPTION]
		LOG.LogError('sagex API error response:\nMSG: %s\nERROR: %s' % (self._msg, self._err))
		LOG.LogDebug('sagex API exception:\n%s' % self._exception)

	def __str__(self):
		return self._err

class SageApiIoException(SageApiException):
	'''Thrown when an IO error occurs during an API call; usually a network error.'''

	_data = None
	_cause = None
	_msg = None

	def __init__(self, data, cause = None):
		self._data = data
		self._cause = cause
		msg = self._data
		if(self._cause != None):
			msg += ' [cause: ' + str(self._cause) + ']'
		self._msg = msg
		LOG.LogError('SageApiIOException: %s' % msg)

	def __str__(self):
		return self._msg

class _SageApiFancyUrlOpener(urllib.FancyURLopener):
	'''Extends FancyURLopener by simply throwing an exception in response to a 401 from the remote server.'''
	def prompt_user_passwd(self, host, realm):
		raise SageApiIoException('Invalid Sagex API id or password!')

class _SageLogger:

	_log = None

	def __init__(self, target = None):
		if(IS_BOXEE_ENV):
			self._log = mc
		else:
			logging.basicConfig(filename = target, level = logging.DEBUG)
			self._log = _SageDebugLogger()

	def LogError(self, msg):
		try:
			self._log.LogError(str(msg))
		except TypeError:
			self._log.LogError(msg.encode(DEFAULT_CHARSET))

	def LogInfo(self, msg):
		try:
			self._log.LogInfo(str(msg))
		except TypeError:
			self._log.LogInfo(msg.encode(DEFAULT_CHARSET))

	def LogDebug(self, msg):
		try:
			self._log.LogDebug(str(msg))
		except TypeError:
			self._log.LogDebug(msg.encode(DEFAULT_CHARSET))

class _SageDebugLogger:

	def LogError(self, msg):
		logging.error(msg)

	def LogInfo(self, msg):
		logging.info(msg)

	def LogDebug(self, msg):
		logging.debug(msg)
		
class IconStatusThread(threading.Thread):
	_status = dict()
	_keepAlive = True
	_api = None
	_lock = None
	_activeWindow = 14000

	def __init__(self, api):
		threading.Thread.__init__(self)
		self._api = api
		self._lock = RLock()

	def run(self):
		LOG.LogInfo('SageBoxee: sage.py: Icon status thread started.')
		iconPath = mc.GetApp().GetAppMediaDir() + "/media/"
		numberFails = 0
		while 1:
			try:
				self._lock.acquire()
				try:
				
					# Setting activeWindow to 0 disables updating.
					if self._activeWindow != 0:
				
						# Set visibility of Conflict icon.
						self._status['IsUnresolvedConflicts'] = self._api.AreThereUnresolvedConflicts()
						mc.GetWindow(self._activeWindow).GetControl(902).SetVisible(self._status['IsUnresolvedConflicts'])
					
						# Set visibility of Recording icon.
						self._status['IsRecording'] = self._api.isRecordingSomething()
						mc.GetWindow(self._activeWindow).GetControl(901).SetVisible(self._status['IsRecording'])
					
						# Set visibility of Library Import Scan icon.
						self._status['IsScanning'] = self._api.IsDoingLibraryImportScan()
						mc.GetWindow(self._activeWindow).GetControl(900).SetVisible(self._status['IsScanning'])
					
						# Set alert level.
						self._status['AlertLevel'] = self._api.GetSystemAlertLevel()
						statusControl = mc.GetWindow(self._activeWindow).GetControl(899)
						statusIcon = mc.GetWindow(self._activeWindow).GetImage(899)
						#statusLevel = api.GetSystemAlertLevel()
						statusLevel = self._status['AlertLevel']
						if (statusLevel == 0):
							statusControl.SetVisible(False)
						elif (statusLevel == 1):
							statusIcon.SetTexture(iconPath + "MarkerSysAlert1.png")
							statusControl.SetVisible(True)
						elif (statusLevel == 2):
							statusIcon.SetTexture(iconPath + "MarkerSysAlert2.png")
							statusControl.SetVisible(True)
						elif (statusLevel == 3):
							statusIcon.SetTexture(iconPath + "MarkerSysAlert3.png")
							statusControl.SetVisible(True)
							
						numberFails = 0
					
				except Exception, e:
					# When the player is active the update will fail so check for that condition.
					if not livetv.isPlayerActive():
						LOG.LogError('Boxee: Error updating icon status! [%s].' % str(e))
						numberFails += 1
						mc.LogDebug("SageBoxee: Number of failures = " + str(numberFails))
						if numberFails > 5:
							mc.LogDebug("SageBoxee: Terminating IconUpdater thread.")
							self._keepAlive = False
				if(not self._keepAlive):
					break
			finally:
				self._lock.release()
			sleep(10)
		LOG.LogInfo('SageBoxee: sage.py: Icon status thread has terminated!')

	def getStatus(self, id = None):
		self._lock.acquire()
		if(id == None):
			rc = self._status.copy()
		else:
			rc = self._status[id]
		self._lock.release()
		return rc

	def setKeepAlive(self, val):
		self._lock.acquire()
		self._keepAlive = val
		self._lock.release()
		
	def setActiveWindow(self, val):
		self._lock.acquire()
		LOG.LogInfo('SageBoxee: sage.py: New activeWindow is ' + str(val))
		self._activeWindow = val
		self._lock.release()
		
	def getActiveWindow(self):
		return self._activeWindow
		
def stopIconStatusThread(t):
	t.setKeepAlive(False)
	
def getRunningThread(name):
	for t in threading.enumerate():
		if t.getName() == name:
			mc.LogDebug("SageBoxee: Found running thread with name " + name)
			return t
	mc.LogDebug("SageBoxee: Did not find running thread with name " + name)
	return None

def _objToArg(obj):
	'''Convert a ListItem or JSON object to an appropriate sagex HTTP argument.

	Allows passing of ListItem or JSON objects as arguments to sagex remote HTTP API calls.
	'''
	if IS_BOXEE_ENV and type(obj) is mc.ListItem:
		if len(obj.GetProperty('MediaFileID')) > 0:
			return 'mediafile:%s' % obj.GetProperty('MediaFileID')
		elif len(obj.GetProperty('AiringID')) > 0:
			return 'airing:%s' % obj.GetProperty('AiringID')
		elif len(obj.GetProperty('ShowID')) > 0:
			return 'show:%s' % obj.GetProperty('ShowID')
	elif type(obj) is dict:
		if obj.has_key('MediaFileID'):
			return 'mediafile:%d' % obj['MediaFileID']
		elif obj.has_key('AiringID'):
			return 'airing:%d' % obj['AiringID']
		elif obj.has_key('ShowExternalID'):
			return 'show:%s' % obj['ShowExternalID']
	try:
		payload = str(obj)
	except UnicodeEncodeError:
		payload = obj.encode(DEFAULT_CHARSET)
	return payload

LOG             = _SageLogger()

PROP_ERROR      = 'error'
PROP_EXCEPTION  = 'exception'

DEFAULT_CHARSET = 'utf-8'

def comparePluginVersions(s1, s2):

	# See if they are equal.
	if s1 == s2:
		return 0
	
	# Make sure they are the same length.
	str1 = normalizePluginString(s1, len(string.split(s2, '.')))
	str2 = normalizePluginString(s2, len(string.split(s1, '.')))
	
	# Split into parts separated by '.'
	p1 = string.split(str1, '.')
	p2 = string.split(str2, '.')
	
	for i in range(len(p1)):
		int1 = int(p1[i])
		int2 = int(p2[i])
		if int1 < int2:
			return -1
		elif int2 < int1:
			return 1

	return 0
	
def normalizePluginString(s, l):
	while len(string.split(s, '.')) < l:
		s += ".0"
	return s
			